import { CommandActionHandler, ArriveActionHandler, ConditionHandler, InputInfo, WorkflowInfo } from './context';
import { FlowInstance, FlowVariables } from './instance';
import { WorkflowManager } from './manager';
import { BeginNode, EndNode, FlowNode, NormalNode } from './node';
import { UserPlayer, FlowPlayer } from './player';
import { asyncMap } from './utils';

export class WorkflowDefinitionError extends Error {
	constructor(message: string) {
		super(message);
	}
}

export class WorkflowError extends Error {
	constructor(flowId: string, message: string) {
		super(`Workflow [${flowId}] error: ${message}`);
	}
}

export interface FlowPreInput {
	/**
	 * Changed form data (could be undefined, means no change happend or in start node)
	 */
	data?: unknown;
	/**
	 * The node id which the action belonging of.
	 */
	currentNodeId: string;
	/**
	 * The node appear id of the current node.
	 */
	currentNodeAppearId: number;
	/**
	 * Which action caused move on, provide empty string for start node.
	 */
	currentActionId: string;
	/**
	 * The current operator
	 */
	currentPlayer: UserPlayer;
	/**
	 * User provided opinion(optional)
	 */
	opinion?: string;

	/**
	 * Used for pass extra context info to workflow event handler.
	 */
	extra?: unknown;
}

export interface FlowInput extends FlowPreInput {
	/**
	 * Players for next arrive node
	 */
	choosedPlayers?: Record<string, UserPlayer[]>;
	/**
	 * Whether is a testing for choose candidates
	 */
	test?: boolean;
}

export interface FlowStartOptions {
	/**
	 * Used for pass extra context info to workflow event handler.
	 */
	extra?: unknown;

	initVariables?: FlowVariables;

	/**
	 * Workflow applyer
	 */
	player: UserPlayer;
}

export interface NodePlayers {
	nodeId: string;
	choosePlayer: boolean;
	candidates: FlowPlayer[];
}

export class Workflow {
	readonly nodes: FlowNode[] = [];
	constructor(public id: string, public name: string, public version: string) {}

	addNodeObj(node: FlowNode): this {
		node.flowId = this.id;
		this.nodes.push(node);
		return this;
	}

	addBeginNode(id: string, name: string): BeginNode {
		const node = new BeginNode(id, name);
		this.addNodeObj(node);
		return node;
	}

	addNode(id: string, name: string): NormalNode {
		const node = new NormalNode(id, name);
		this.addNodeObj(node);
		return node;
	}

	addEndNode(id: string, name: string): EndNode {
		const node = new EndNode(id, name);
		this.addNodeObj(node);
		return node;
	}

	getNode(id: string): FlowNode {
		const node = this.nodes.find((node) => node.id === id);
		if (!node) throw new WorkflowError(this.id, `node [${id}] not exist`);
		return node;
	}
	/**
	 * Check whether workflow are correctly defined
	 * @throws {WorkflowDefinitionError}
	 */
	validate(): void {
		if (this.nodes.length < 2) {
			throw new WorkflowDefinitionError(`Workflow [${this.id}] should have at least 2 nodes`);
		}
		const startCount = this.nodes.filter((n) => n.type === 'begin').length;
		if (startCount < 1) {
			throw new WorkflowDefinitionError(`Workflow [${this.id}] should have one begin node`);
		}
		if (startCount > 1) {
			throw new WorkflowDefinitionError(`Workflow [${this.id}] should have only one end node`);
		}
		if (!this.nodes.some((n) => n.type === 'end')) {
			throw new WorkflowDefinitionError(`Workflow [${this.id}] should have at least one stop node`);
		}
		const nodeIds = this.nodes.reduce((s, n) => (s.add(n.id), s), new Set<string>());
		const arrivedNodes = new Set<string>();
		this.nodes.forEach((node) => {
			if (FlowNode.isBeginOrNormal(node)) {
				const nextNodes: string[] = [];
				if (node.players.length < 1) {
					throw new WorkflowDefinitionError(
						`Workflow [${this.id}]'s node [${node.id}] should have at least one player`
					);
				}
				if (node.actions.length < 1) {
					throw new WorkflowDefinitionError(
						`Workflow [${this.id}]'s node [${node.id}] should have at least one action`
					);
				}
				node.actions.forEach((action) => {
					if (action.nextNodes.length === 0 && typeof action.onAction !== 'function') {
						throw new WorkflowDefinitionError(
							`Workflow [${this.id}]'s node [${node.id}]'s action [${action.id}] should either provide next nodes or 'onAction' callback`
						);
					}
					action.nextNodes.forEach((nodeId) => {
						if (!nodeIds.has(nodeId)) {
							throw new WorkflowDefinitionError(
								`Workflow [${this.id}]'s node [${node.id}]'s action [${action.id}]'s next node [${nodeId}] not exist`
							);
						}
						nextNodes.push(nodeId);
						arrivedNodes.add(nodeId);
					});
				});
				if (nextNodes.length === 0) {
					throw new WorkflowDefinitionError(
						`Workflow [${this.id}]'s node [${node.id}] should have at least one successor node`
					);
				}
			}
		});
		this.nodes.forEach((node) => {
			if (!FlowNode.isBegin(node)) {
				if (!arrivedNodes.has(node.id)) {
					throw new WorkflowDefinitionError(
						`Workflow [${this.id}]'s node [${node.id}] should have at least one predecessor node`
					);
				}
			}
		});
	}

	async newInstance(manager: WorkflowManager, instanceId: string, options: FlowStartOptions): Promise<FlowInstance> {
		const node = this.nodes.find((n) => n.type === 'begin');
		if (!node) throw new WorkflowError(this.id, 'Start node not found');
		if (!FlowNode.isBegin(node)) {
			throw new WorkflowError(this.id, 'Workflow can only be launched from the begin node');
		}
		const instance: FlowInstance = {
			id: instanceId,
			flowId: this.id,
			flowVersion: this.version,
			version: 0,
			finished: false,
			dataVersion: 0,
			data: null,
			variables: options.initVariables ?? {},
			nodeAppearId: { [node.id]: 1 },
			nodeVariables: {},
			currentNodes: [{ nodeId: node.id, appearId: 1 }],
			abortReason: undefined,
			records: [
				{
					timestamp: Date.now(),
					nodeId: node.id,
					nodeAppearId: 1,
					player: options.player,
					opinion: '起草',
					dataVersion: 0,
					nextNodes: [],
				},
			],
		};
		if (typeof node.onArrive === 'function') {
			const context = new ArriveActionHandler(manager, this, instance, node.id, 1, options.extra, {}, [options.player]);
			await node.onArrive(context);
		}
		return instance;
	}

	async go(manager: WorkflowManager, instance: FlowInstance, input: FlowInput): Promise<void> {
		if (instance.finished) {
			throw new WorkflowError(this.id, `Workflow has been finished`);
		}
		const node = this.getNode(input.currentNodeId);
		if (!FlowNode.isBeginOrNormal(node)) {
			throw new WorkflowError(this.id, 'Workflow cannot go next from the end node');
		}
		instance.version++;
		const inputInfo: InputInfo = {
			player: input.currentPlayer,
			nodeId: input.currentNodeId,
			nodeAppearId: input.currentNodeAppearId,
			actionId: input.currentActionId,
			data: input.data,
			opinion: input.opinion,
			choosedPlayers: input.choosedPlayers,
			test: input.test ?? false,
		};
		const action = node.getAction(input.currentActionId);
		if (
			action.condition &&
			!(await action.condition.match(
				new ConditionHandler(this, instance, input.currentNodeId, input.currentNodeAppearId, input.extra, inputInfo)
			))
		) {
			throw new WorkflowError(
				this.id,
				`node [${input.currentNodeId}]'s action [${input.currentActionId}] cannot be performed if condition not matched`
			);
		}
		if (typeof action.onAction === 'function') {
			const handler = new CommandActionHandler(
				manager,
				this,
				instance,
				input.currentNodeId,
				input.currentNodeAppearId,
				input.extra,
				inputInfo
			);
			await action.onAction(handler);
		} else if (action.nextNodes.length > 0) {
			const handler = new CommandActionHandler(
				manager,
				this,
				instance,
				input.currentNodeId,
				input.currentNodeAppearId,
				input.extra,
				inputInfo
			);
			await handler.go(inputInfo, ...action.nextNodes);
		}
	}

	async getCandidatePlayers(
		manager: WorkflowManager,
		instance: FlowInstance,
		input: FlowPreInput
	): Promise<NodePlayers[]> {
		const inst = JSON.parse(JSON.stringify(instance)) as FlowInstance;
		await this.go(manager, inst, {
			data: input.data,
			currentNodeId: input.currentNodeId,
			currentNodeAppearId: input.currentNodeAppearId,
			currentActionId: input.currentActionId,
			currentPlayer: input.currentPlayer,
			opinion: input.opinion,
			extra: input.extra,
			test: true,
		});
		return await asyncMap(inst.currentNodes, async (currentNode) => {
			const node = this.getNode(currentNode.nodeId);
			if (FlowNode.isBeginOrNormal(node)) {
				return {
					nodeId: currentNode.nodeId,
					choosePlayer: node.choosePlayer,
					candidates: (
						await asyncMap(node.players, async (p) => {
							if (FlowPlayer.isCustomPlayer(p)) {
								return await p.getPlayers(instance, input);
							} else {
								return [p];
							}
						})
					).reduce((arr, p) => {
						arr.push(...p);
						return arr;
					}, []),
				};
			} else {
				return {
					nodeId: currentNode.nodeId,
					choosePlayer: node.choosePlayer,
					candidates: [],
				};
			}
		});
	}

	static toWorkflowInfo(workflow: Workflow): WorkflowInfo {
		return {
			id: workflow.id,
			name: workflow.name,
			version: workflow.version,
			nodes: workflow.nodes.map((n) => FlowNode.toNodeInfo(n)),
		};
	}
}
